Skip to content

ci: stop moot merge-queue runs from ejecting healthy PRs from CodeQL - #3633

Merged
kojiwakayama merged 2 commits into
mainfrom
fix/codeql-moot-merge-queue-run
Aug 12, 2026
Merged

kojiwakayama merged 2 commits into
mainfrom
fix/codeql-moot-merge-queue-run

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The defect

The merge queue ejects healthy PRs because CodeQL reports a failure for a run that is no longer attached to anything.

##[warning] ref 'refs/heads/gh-readonly-queue/main/pr-3626-2b3a0e096...' not found in this repository
##[error]   ref 'refs/heads/gh-readonly-queue/main/pr-3626-2b3a0e096...' not found in this repository
CodeQL job status was configuration error.

Mechanism, verified

A merge-queue batch dissolves while the CodeQL job is still running. GitHub deletes the ephemeral gh-readonly-queue/** branch, and the SARIF upload — which only starts about ten minutes in, after the analysis itself has already succeeded — 404s against the ref that no longer exists. The queue reads that red check as the PR's fault and ejects it.

Evidence from run 31573922804 (CodeQL, merge_group, pr-3626):

Time (UTC) Event
07:25:21 #3626 added_to_merge_queue
07:25:39 CodeQL and CI/CD start on head SHA 570f612f
07:31:50 CI/CD passes on that identical head SHA
07:32:52 queue starts building pr-3624 on base 2b3a0e096 — the pr-3626 batch is gone
07:35:51 CodeQL finishes analysis, exports SARIF, begins Uploading results
07:36:06 upload 404s: ref ... not found in this repository
07:40:00 #3626 removed_from_merge_queue

Three things this pins down:

  1. The analysis succeeded. The log shows Exported results to SARIF (524ms) and CodeQL scanned 5702 out of 5789 TypeScript files. The only thing that failed is the upload.
  2. The code is fine. Analyze was the only failing check of 28 on 570f612f, and docs: remove stale DISTRIBUTION.md #3626 is a pure 621-line file deletion.
  3. The failure caused the ejection, not the reverse. The dequeue at 07:40:00 lands after the CodeQL failure at 07:36:11, not before.

Confirmed the deleted ref really is gone, and that a live ref answers normally — this is what the guard keys off:

GET /repos/veryfront/veryfront-code/git/ref/heads/gh-readonly-queue/main/pr-3626-2b3a0e096...  -> HTTP 404
GET /repos/veryfront/veryfront-code/git/ref/heads/main                                        -> HTTP 200

How often this actually bites

An earlier draft of this description claimed four healthy runs were ejected today. That number was inflated: it counted every run carrying the ref-404 signature, without checking whether the run had other failures that would have ejected it anyway. Corrected against the check-run data for each SHA:

SHA PR Started (UTC) Other failing checks Attributable to this bug
349c4ec7 #3606 04:48 tests (bun) no — confounded
aa4ee6cb #3626 06:28 tests (unit), coverage gate, coverage shard 4/8 no — confounded
570f612f #3626 07:25 none (1 of 28 failed) yes
046ed751 #3624 07:32 none (1 of 28 failed) yes
ff57ccf2 #3626 07:38 tests (binary e2e) no — confounded

All five carry the identical ref ... not found in this repository signature, so the bug is real and recurrent. But only two of them — 570f612f (#3626) and 046ed751 (#3624) — were otherwise entirely green, and those are the only ejections this change would have prevented. The other three had genuine failures and belonged out of the queue.

e0cc27da, an adjacent attempt on the same queue branch, is not in the table at all: its CodeQL run succeeded, and it was ejected for tests (unit), coverage gate and coverage shard 4/8.

Two per day is still worth fixing — CodeQL takes ~10 minutes while CI/CD takes ~6, so CodeQL is structurally the job still in flight when a batch dissolves, and the rate scales with queue churn.

What Analyze actually gates

Worth stating plainly, because it bounds how much this change can cost.

Analyze is a required status check on main:

$ gh api repos/veryfront/veryfront-code/branches/main/protection --jq '.required_status_checks.contexts'
["ci (format)","ci (lint)","ci (typecheck)","tests (unit)","tests (integration)",
 "coverage gate","tests (rsc browser e2e)","tests (binary e2e)","Analyze"]

What it gates is "did the analysis run and upload successfully" — not "are there findings". On this repo:

  • codeql-action/analyze@v4.37.6 has no fail-on-findings input. Its inputs are add-snippets, category, check_name, checkout_path, cleanup-level, expect-error, matrix, output, post-processed-sarif-path, ram, ref, sha, skip-queries, threads, token, upload, upload-database, wait-for-processing — nothing that fails the job on a result.
  • Code-scanning merge protection is not enabled (code-scanning/default-setup reports state: not-configured).
  • No code-scanning context appears in the required checks above.
  • Alert fix: embed framework sources in cross-compiled binaries #278 (js/incomplete-multi-character-sanitization, high) has been open since 2026-08-11T19:19Z, and 20+ PRs have merged to main since. Four alerts are open in total.

So this guard cannot suppress a finding-based block, because no such block exists here. It affects only whether a run that produced no usable upload is allowed to fail the queue.

On removing the merge_group trigger

The reasoning holds — do not remove it. The merge queue waits for exactly the required contexts to report on the merge_group ref. Drop the trigger and the check never arrives; the queue would sit until check_response_timeout (1800s) expires and then eject the PR anyway — the same symptom, six times slower. The comment in the workflow is correct and stays.

Why not continue-on-error on the job

A blanket continue-on-error: true on analyze would make every CodeQL failure non-blocking on every event, including push and schedule, where a broken analysis means the repo silently stops being scanned. Not done.

The fix

There is no supported upstream mechanism. github/codeql-action#1572 is this exact error, open since March 2023; a maintainer said they were "discussing internally how best to support merge queue" and nothing shipped. The analyze action's inputs offer no way to say "this run is moot". So the guard is hand-rolled, deliberately narrow, and fails closed.

The analyze step's outcome is captured rather than failing the job outright, and a guard step re-raises it unless the target ref has provably gone away:

Condition Result
merge_group, ref returns 404 — deleted moot → pass
merge_group, ref returns 200 but SHA ≠ ours — batch rebuilt without us moot → pass
merge_group, ref returns 200 at our SHA — run is live fail
merge_group, ref returns 200 with no readable SHA fail closed
merge_group, any other HTTP status or transport error fail closed
pull_request / push / schedule fail

What the tolerant path really swallows

Stated honestly, because the first draft of the workflow comment overstated it: the guard tolerates any analyze failure on a merge_group run whose ref has vanished or moved — not only the upload 404. An extractor crash or a query failure landing in the same window is swallowed too.

That is safe for one reason: check runs are bound to a SHA. If the queue branch is gone or has moved, nothing can be merged on the strength of this run, and the rebuilt entry re-runs CodeQL from scratch on a new SHA. A moot pass can never let unreviewed code through; it only stops the queue attributing a dead run to a live PR.

This rests on the queue's merge_method being SQUASH, which mints a fresh SHA for every rebuild, so a moot run's SHA can never recur on a later live entry. Under REBASE, a no-op rebase could reproduce the same SHA and let a moot green satisfy a live entry. rebaseMergeAllowed is true on this repo, so the queue's method is the only thing closing that hole — the assumption is now recorded in the workflow so a future config change does not silently reopen it.

Verification

The guard script is extracted from the committed YAML and executed by bash against a real local HTTP server and real transport failures, so the actual curl invocation — flags, retries, -w sentinel — is under test rather than a re-implementation.

result exit    tmp  sum  case
----------------------------------------------------------------------------------------
ok        0  clean  yes  merge_group + ref deleted (404)
ok        0  clean  yes  merge_group + ref moved to new SHA
ok        1  clean   no  merge_group + ref live at our SHA
ok        1  clean   no  F1 merge_group + 200 body {}
ok        1  clean   no  F1 merge_group + 200 {"object":null}
ok        1  clean   no  F1 merge_group + 200 {"object":{}} (sha absent)
ok        1  clean   no  F1 merge_group + 200 {"object":{"sha":""}}
ok        1  clean   no  F1 merge_group + 200 unparseable JSON
ok        1  clean   no  F1 merge_group + 200 empty body
ok        1  clean   no  merge_group + API 500
ok        1  clean   no  merge_group + API 403
ok        1  clean   no  F2 merge_group + connection refused
ok        1  clean   no  F2 merge_group + DNS failure
ok        1  clean   no  pull_request failure
ok        1  clean   no  push to main failure
ok        1  clean   no  schedule failure
ok        1  clean   no  F3 merge_group + hung connection (timeout)
----------------------------------------------------------------------------------------
passed=17 failed=0

tmp asserts the mktemp body was removed; sum asserts a $GITHUB_STEP_SUMMARY record exists on exactly the tolerated paths. Every case also asserts the transport sentinel is three zeroes, never 000000.

Running the same suite against the previous revision of this branch fails 10 of 16, which is what makes the suite worth anything:

FAIL      0  clean   no  F1 merge_group + 200 body {}
FAIL      0  clean   no  F1 merge_group + 200 {"object":null}
FAIL      0  clean   no  F1 merge_group + 200 {"object":{}} (sha absent)
FAIL      0  clean   no  F1 merge_group + 200 {"object":{"sha":""}}
FAIL      5  clean   no  F1 merge_group + 200 unparseable JSON
FAIL      0  clean   no  F1 merge_group + 200 empty body
FAIL      1  clean   no  F2 merge_group + connection refused
FAIL      1  clean   no  F2 merge_group + DNS failure

Those exit 0s are a real fail-open, and they printed:

::notice::refs/heads/... has moved from 570f612f8a3c41d9be27fa5d6c18e4b93d72a1ce to : the batch was rebuilt without this run. Treating it as moot.

An empty SHA compared unequal to ours and fell into the tolerant path. It now fails closed and says the response was unreadable. The unparseable-JSON case exited 5 — jq aborting under set -e with no diagnostic at all.

shellcheck 0.11.0 is clean on the extracted script.

Trade-off

This is a workaround for a GitHub-side interaction, and it owns two risks:

  • It is hand-rolled, against the general preference for a supported mechanism. Nothing supported exists; if upstream ever ships one, this should be deleted in favour of it.
  • It narrows the gate in one specific case: a genuine CodeQL failure that coincides with the batch dissolving is reported as moot. That window is real but harmless for the SHA-binding reason above — that run cannot gate a merge either way, and the rebuilt entry re-runs CodeQL from scratch.

I considered upload: never for merge_group, which is a documented input and would remove the 404 deterministically. Rejected: it drops the SARIF upload on every queue run, not just the broken ones, and would silently break code-scanning merge protection if it is ever enabled here.

Note

This PR has to survive the very queue it is fixing, on the old workflow — the fix only takes effect for runs after it lands. If its own CodeQL check goes red with the ref-not-found signature, that is the bug reproducing itself, not a reason to doubt the change.


🤖 Generated with Claude Code

Summary by CodeRabbit

  • Chores
    • Improved the reliability of automated security analysis by adding a defined execution timeout.
    • Enhanced handling of transient analysis failures and merge-queue conditions.
    • Added safeguards to prevent inconclusive checks from being incorrectly accepted.
    • Tolerated, expected merge-queue failures are now clearly recorded in job summaries.

A merge-queue batch can dissolve while the CodeQL job is still running.
GitHub deletes the ephemeral gh-readonly-queue ref, and the SARIF upload --
which begins ~10 minutes in, after the analysis has already succeeded --
fails with "ref ... not found in this repository". Nothing is being merged
at that point, so the run is moot, but the queue reads the red check as the
PR's fault and ejects it.

Observed four times on 2026-08-12 (#3626 twice, #3624, #3606). On run
31573922804 the sibling CI/CD run for the identical head SHA passed, and
the PR was a pure file deletion that cannot produce a security finding.

Capture the analyze step's outcome and re-raise it unless the target ref has
provably gone away, or moved to a different SHA because the batch was
rebuilt without this run. Everything else -- any non-merge_group event, a
ref still live at our SHA, or an indeterminate API response -- still fails
the build, so a genuine finding is unaffected.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The CodeQL workflow now limits analysis to 25 minutes. It conditionally tolerates failures for merge-group runs when the target ref is missing or moved, while failing closed for other conditions and recording tolerated failures.

Changes

CodeQL failure handling

Layer / File(s) Summary
CodeQL timeout and failure guard
.github/workflows/codeql.yml
The job sets a 25-minute timeout. Failed analysis triggers bounded GitHub ref API retries. The workflow tolerates only qualifying merge-group ref states, fails for other responses, and records tolerated failures in the job summary.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preventing moot merge-queue CodeQL runs from ejecting healthy pull requests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/codeql-moot-merge-queue-run

Comment @coderabbitai help to get the list of available commands.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
@kojiwakayama
kojiwakayama removed this pull request from the merge queue due to a manual request Aug 12, 2026
Audit follow-up on the moot merge-queue guard. Six fixes, no restructuring.

The 200 branch read the ref SHA with `jq -r '.object.sha // empty'` and
compared it to ours. A 200 carrying valid JSON without an object SHA left
the variable empty, which compares unequal and fell straight into the
tolerant "batch was rebuilt without us" path -- exit 0, failure swallowed.
Executing the old script against bodies `{}`, `{"object":null}`,
`{"object":{}}` and an empty body reproduces it: all exit 0 and print the
self-evidently broken `has moved from <sha> to :`. An unparseable body was
no better, aborting on jq's exit 5 with no diagnostic. An absent SHA now
fails closed and says the response was unreadable.

curl already emits 000 through -w on a transport failure, so the
`|| echo "000"` fallback appended a second sentinel: the status became the
literal `000000`, and the operator-facing message read
`(HTTP 000000)`. Measured with curl 8.7.1 for connection refused, DNS
failure and timeout. Classification was unaffected -- it still fell to the
catch-all and failed closed -- but the message was wrong. Removed.

The request had no timeout and no retry, and the job had no
timeout-minutes. A hang against api.github.com would have held the job for
the Actions default of 360 minutes, far past the queue's 1800s
check_response_timeout, reproducing the very ejection this guard prevents.
Bounded to ~2 minutes worst case and capped the job at 25.

The guard's safety rests on the queue's merge_method being SQUASH, which
mints a fresh SHA per rebuild so a moot run's SHA cannot recur on a live
entry. Under REBASE a no-op rebase could repeat a SHA and let a moot green
satisfy a later live entry. Not reachable today, but rebaseMergeAllowed is
true on this repo, so the assumption is now recorded where it can be seen.

The step comment claimed only a vanished ref is tolerated. It actually
swallows any analyze failure coinciding with dissolution, an extractor
crash included. Safe for the same SHA-binding reason, but now stated.

Tolerated paths append to GITHUB_STEP_SUMMARY, since turning a red required
check green was previously visible only in the raw log. The mktemp body is
removed on exit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/workflows/codeql.yml (1)

102-114: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider passing the token through stdin instead of argv.

-H "Authorization: Bearer ${GH_TOKEN}" places the token in the process arguments. Any process on the runner can read it from the process table. The runner is ephemeral and the token is scoped to the run, so the exposure is small. Reading the header from stdin removes it.

The retry and timeout bounds themselves look correct. curl runs without -f, so 4xx and 5xx responses still return exit code 0 and real status codes, and --retry-all-errors does not retry the 404 the guard depends on.

♻️ Proposed refactor to keep the token off argv
-          if ! status="$(curl -sS \
+          if ! status="$(printf 'header = "Authorization: Bearer %s"\n' "$GH_TOKEN" | curl -sS \
+            --config - \
             --connect-timeout 10 \
             --max-time 30 \
             --retry 3 \
             --retry-delay 2 \
             --retry-all-errors \
             -o "$body" -w '%{http_code}' \
-            -H "Authorization: Bearer ${GH_TOKEN}" \
             -H "Accept: application/vnd.github+json" \
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/codeql.yml around lines 102 - 114, Update the curl
invocation in the CodeQL workflow to supply the GitHub authorization header
through stdin rather than embedding GH_TOKEN in the command-line arguments,
while preserving the existing URL, status capture, retry, timeout, and
error-handling behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/codeql.yml:
- Around line 136-148: Validate current after parsing in the HTTP 200 branch,
requiring exactly one 40-character hexadecimal SHA before comparing it with
TARGET_SHA. Treat empty, multi-line, or otherwise malformed values as
undetermined and retain the existing failing-closed error path; only valid SHAs
may reach the rebuilt-batch comparison.

---

Nitpick comments:
In @.github/workflows/codeql.yml:
- Around line 102-114: Update the curl invocation in the CodeQL workflow to
supply the GitHub authorization header through stdin rather than embedding
GH_TOKEN in the command-line arguments, while preserving the existing URL,
status capture, retry, timeout, and error-handling behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3335f56d-7ad2-4a7d-a071-3f314bd59bef

📥 Commits

Reviewing files that changed from the base of the PR and between f4e9f06 and c8813c7.

📒 Files selected for processing (1)
  • .github/workflows/codeql.yml

Comment on lines +136 to +148
if ! current="$(jq -r '.object.sha // empty' "$body")"; then
current=""
fi
if [ -z "$current" ]; then
echo "::error::${TARGET_REF} returned HTTP 200 but the response carried no object SHA," \
"so whether this run is still live cannot be determined. Failing closed."
exit 1
fi
if [ "$current" = "$TARGET_SHA" ]; then
echo "::error::${TARGET_REF} still points at ${TARGET_SHA}, so this run is live." \
"The CodeQL failure is genuine. Failing the build."
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the shape of current before the moot branch.

The 200 branch fails closed on an empty or unparseable SHA. It does not check that current is a single SHA. jq -r '.object.sha // empty' emits one line per JSON document in $body. If $body ever holds more than one document, current becomes a multi-line string. That string is non-empty and unequal to TARGET_SHA, so control reaches the tolerant "batch was rebuilt" path and reports a failing run as successful. Restricting current to one 40-hex value keeps the branch closed for every malformed body.

🛡️ Proposed fix to constrain the parsed SHA
-              if ! current="$(jq -r '.object.sha // empty' "$body")"; then
+              if ! current="$(jq -er 'if type == "object" then (.object.sha // empty) else empty end' "$body")"; then
                 current=""
               fi
-              if [ -z "$current" ]; then
+              case "$current" in
+                *[!0-9a-f]* | "") current="" ;;
+              esac
+              if [ -z "$current" ] || [ "${`#current`}" -ne 40 ]; then
                 echo "::error::${TARGET_REF} returned HTTP 200 but the response carried no object SHA," \
                   "so whether this run is still live cannot be determined. Failing closed."
                 exit 1
               fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ! current="$(jq -r '.object.sha // empty' "$body")"; then
current=""
fi
if [ -z "$current" ]; then
echo "::error::${TARGET_REF} returned HTTP 200 but the response carried no object SHA," \
"so whether this run is still live cannot be determined. Failing closed."
exit 1
fi
if [ "$current" = "$TARGET_SHA" ]; then
echo "::error::${TARGET_REF} still points at ${TARGET_SHA}, so this run is live." \
"The CodeQL failure is genuine. Failing the build."
exit 1
fi
if ! current="$(jq -er 'if type == "object" then (.object.sha // empty) else empty end' "$body")"; then
current=""
fi
case "$current" in
*[!0-9a-f]* | "") current="" ;;
esac
if [ -z "$current" ] || [ "${#current}" -ne 40 ]; then
echo "::error::${TARGET_REF} returned HTTP 200 but the response carried no object SHA," \
"so whether this run is still live cannot be determined. Failing closed."
exit 1
fi
if [ "$current" = "$TARGET_SHA" ]; then
echo "::error::${TARGET_REF} still points at ${TARGET_SHA}, so this run is live." \
"The CodeQL failure is genuine. Failing the build."
exit 1
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/codeql.yml around lines 136 - 148, Validate current after
parsing in the HTTP 200 branch, requiring exactly one 40-character hexadecimal
SHA before comparing it with TARGET_SHA. Treat empty, multi-line, or otherwise
malformed values as undetermined and retain the existing failing-closed error
path; only valid SHAs may reach the rebuilt-batch comparison.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
@kwakayama
kwakayama removed this pull request from the merge queue due to a manual request Aug 12, 2026
@kwakayama kwakayama added the needs-human-input Maintainer action required label Aug 12, 2026
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 1ca03fa Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/codeql-moot-merge-queue-run branch August 12, 2026 09:09
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human-input Maintainer action required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants